1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { Suspense } from 'react';5import { PageHeader } from '@/components/ui/page-header';6import { Card, CardHeader, Delta, EmptyState, Skeleton, Stat, Badge, Unavailable } from '@/components/ui/primitives';7import { MarketTable } from '@/components/market/market-table';8import { AssetBrowser } from '@/components/market/asset-browser';9import { Thumb } from '@/components/market/bits';10import type { SP } from '@/lib/search-params';11import { AssetTable } from '@/components/market/asset-list';12import { SalesTable, ListingsTable, LotsTable } from '@/components/market/sales-table';13import { LineChart } from '@/components/charts/line-chart';14import { BarChart } from '@/components/charts/bar-chart';15import { getBrandsInCategory, getCategoryRow, getCategorySeries, getMarketRows, getPopulationTrend, getSetsInCategory } from '@/lib/queries/markets';16import { rankedAssets, categoryScope } from '@/lib/queries/assets';17import { getRecentSalesInScope, getTopSales, listListings, listLots, listNews, listRadar } from '@/lib/queries/market-lists';18import { getIndex } from '@/lib/queries/indices';19import { fmtMoney, fmtNum, fmtRelative, fmtDate } from '@/lib/format';20import { categoryCrumbs, getCategory, humanize } from '@/lib/taxonomy';21import { CATEGORIES } from '@rareindex/taxonomy';2223export const revalidate = 300;2425export async function generateStaticParams() {26 return CATEGORIES.filter((c) => c.phase === 1).map((c) => ({ slug: c.slug }));27}2829export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {30 const { slug } = await params;31 const c = getCategory(slug);32 if (!c) return { title: 'Market not found' };33 return { title: `${c.name} market`, description: `${c.name} collectibles market on RareIndex: index, sales, volume, top movers, most valuable assets, listings and news.` };34}3536export default async function MarketPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<SP> }) {37 const { slug } = await params;38 const sp = await searchParams;39 const row = await getCategoryRow(slug);40 if (!row) notFound();41 const { node, snapshot, counts } = row;42 const index = node.index ? await getIndex(node.index) : null;43 const subcategories = CATEGORIES.filter((c) => c.parent === slug);44 return (45 <div>46 <PageHeader47 crumbs={[{ label: 'Markets', href: '/markets' }, ...categoryCrumbs(slug)]}48 title={node.name}49 description={node.description ?? `${node.name} market data: verified sales, live listings, valuations and category index.`}50 meta={51 <>52 <Badge tone={node.phase === 1 ? 'index' : 'neutral'}>Phase {node.phase}</Badge>53 {index ? (54 <Link href={`/rareindex/${index.ticker}`} className="hover:text-fg">55 Subindex {index.ticker}56 </Link>57 ) : null}58 {node.compliance?.length ? <Badge tone="alert">{node.compliance.map(humanize).join(' · ')}</Badge> : null}59 <Link href={`/explore?category=${slug}`} className="hover:text-fg">60 Explore assets →61 </Link>62 </>63 }64 />6566 <section className="grid grid-cols-2 gap-px overflow-hidden rounded-md border border-border bg-border sm:grid-cols-4 lg:grid-cols-8">67 {[68 ['Tracked assets', fmtNum(counts.assets)],69 ['With valuation', fmtNum(counts.priced)],70 ['Sales (all time)', fmtNum(counts.sales)],71 ['Sales 30D', fmtNum(counts.sales30d)],72 ['Volume 30D', counts.volume30dUsd != null ? fmtMoney(counts.volume30dUsd, 'USD', { compact: true }) : '—'],73 ['Active listings', fmtNum(counts.listings)],74 ['Median RIV', counts.medianRivUsd != null ? fmtMoney(counts.medianRivUsd) : '—'],75 ['Market cap est.', snapshot?.marketCapEstUsd != null ? fmtMoney(snapshot.marketCapEstUsd, 'USD', { compact: true }) : '—'],76 ].map(([k, v]) => (77 <div key={k} className="bg-elevated px-3 py-2.5">78 <Stat label={k} value={v} />79 </div>80 ))}81 </section>82 {snapshot?.marketCapEstUsd != null ? <p className="mt-1 text-[11px] text-subtle">Market cap estimate = estimated population × representative RIV per asset, summed; indicative only (§124).</p> : null}83 {counts.assets > 0 && counts.priced === 0 ? (84 <p className="mt-2 rounded-md border border-border bg-sunken px-3 py-2 text-[12px] text-muted">85 <span className="font-medium text-fg">{fmtNum(counts.assets)} {node.name} assets are catalogued.</span> Valuations publish per asset once enough verified sales exist; guide prices from price-guide sources are shown in the meantime, labelled as such (never presented as market value).86 </p>87 ) : null}8889 <div className="mt-4">90 <Suspense fallback={<Skeleton className="h-[32rem]" />}>91 <AssetBrowser scope={{ category: slug }} sp={sp} basePath={`/markets/${slug}`} title={`Assets in ${node.name}`} subcategories={subcategories} />92 </Suspense>93 </div>9495 {counts.sales > 0 || counts.listings > 0 ? (96 <>97 <Suspense fallback={<Skeleton className="mt-4 h-72" />}>98 <IndexPanel slug={slug} />99 </Suspense>100101 <Suspense fallback={<Skeleton className="mt-4 h-72" />}>102 <Movers slug={slug} />103 </Suspense>104105 <Suspense fallback={<Skeleton className="mt-4 h-72" />}>106 <Activity slug={slug} />107 </Suspense>108 </>109 ) : (110 <Card className="mt-4 p-4">111 <p className="t-subtitle text-fg">Market analytics publish once verified sales or live listings exist in {node.name}.</p>112 <p className="mt-1 text-[13px] leading-relaxed text-muted">113 Movers, volume, records, value opportunities, auctions and the category index all derive from observed transactions. Until then the catalogue above is complete and guide prices are shown where a price-guide source reports them (labelled, never presented as market value).{' '}114 <Link href="/data" className="underline-offset-2 hover:text-fg hover:underline">115 How coverage grows →116 </Link>117 </p>118 </Card>119 )}120121 <Suspense fallback={<Skeleton className="mt-4 h-40" />}>122 <Structure slug={slug} />123 </Suspense>124 </div>125 );126}127128async function IndexPanel({ slug }: { slug: string }) {129 const series = await getCategorySeries(slug, 730);130 const points = series.filter((p) => p.indexValue != null).map((p) => ({ x: p.date, y: p.indexValue! }));131 const salesSeries = series.map((p) => ({ x: p.date, y: p.sales }));132 return (133 <section className="mt-4 grid gap-4 lg:grid-cols-[2fr_1fr]">134 <Card className="p-4">135 <h2 className="text-sm font-semibold">Category index</h2>136 <p className="text-xs text-muted">Daily category index from valuations of priced constituents; publishes once enough assets are priced.</p>137 <div className="mt-2">138 <LineChart ariaLabel={`${slug} category index`} height={220} series={[{ id: 'idx', label: 'Category index', kind: 'area', points }]} emptyLabel="Index building — not enough priced constituents yet" />139 </div>140 </Card>141 <Card className="p-4">142 <h2 className="text-sm font-semibold">Daily sales</h2>143 <p className="text-xs text-muted">Verified transactions per day (count)</p>144 <div className="mt-2">145 <LineChart ariaLabel={`${slug} daily sales`} height={220} series={[{ id: 'sales', label: 'Sales', kind: 'step', points: salesSeries }]} emptyLabel="No snapshot history yet" />146 </div>147 </Card>148 </section>149 );150}151152async function Movers({ slug }: { slug: string }) {153 const scope = categoryScope(slug);154 const [gainers, losers, volume, expensive, trending] = await Promise.all([155 rankedAssets('gainers', { scope, limit: 8, window: '7d' }),156 rankedAssets('losers', { scope, limit: 8, window: '7d' }),157 rankedAssets('volume', { scope, limit: 8 }),158 rankedAssets('expensive', { scope, limit: 8 }),159 rankedAssets('trending', { scope, limit: 8 }),160 ]);161 const block = (title: string, sub: string, items: typeof gainers, columns: Parameters<typeof AssetTable>[0]['columns']) => (162 <Card>163 <CardHeader title={title} subtitle={sub} />164 <AssetTable items={items} columns={columns} emptyTitle="No data yet" emptyDescription="Needs priced assets with sales history in this category." />165 </Card>166 );167 return (168 <section className="mt-4 grid gap-4 xl:grid-cols-2">169 {block('Top gainers · 7D', 'Assets with ≥3 sales used in valuation', gainers, ['riv', 'change7d', 'change30d', 'sales'])}170 {block('Top losers · 7D', 'Assets with ≥3 sales used in valuation', losers, ['riv', 'change7d', 'change30d', 'sales'])}171 {block('Highest volume · 30D', 'Sum of verified sale prices, USD', volume, ['riv', 'sales30d', 'change30d', 'liquidity'])}172 {block('Most valuable', 'Ranked by RareIndex Valuation', expensive, ['riv', 'latestSale', 'change1y', 'rarity'])}173 {block('Trending', 'Price × volume × listing momentum', trending, ['riv', 'trending', 'change7d', 'sales30d'])}174 <Card>175 <CardHeader title="Value opportunities" subtitle="Assets whose lowest ask sits materially below RIV — analytical data, not advice" />176 <AssetTable items={await rankedAssets('opportunity', { scope, limit: 8 })} columns={['riv', 'minAsk', 'opportunity', 'listings']} emptyTitle="No opportunities detected" emptyDescription="Requires both a valuation and active listings." />177 </Card>178 </section>179 );180}181182async function Activity({ slug }: { slug: string }) {183 const scope = categoryScope(slug);184 const [records, recent, listings, lots, news, radar, pop] = await Promise.all([185 getTopSales(6, scope),186 getRecentSalesInScope(scope, 10),187 listListings({ category: slug, pageSize: 10, sort: 'newest' }),188 listLots({ category: slug, limit: 6, sort: 'value' }),189 listNews({ category: slug, limit: 6 }),190 listRadar({ scope, limit: 6 }),191 getPopulationTrend(slug),192 ]);193 const popByGrader = new Map<string, Array<{ x: string; y: number }>>();194 for (const p of pop) (popByGrader.get(p.grader) ?? popByGrader.set(p.grader, []).get(p.grader)!).push({ x: p.reportDate, y: p.total });195 return (196 <section className="mt-4 grid gap-4 xl:grid-cols-2">197 <Card>198 <CardHeader title="Newest records" subtitle="Highest verified sales in this market" action={<Link href={`/records`} className="text-muted hover:text-fg">All records →</Link>} />199 <SalesTable items={records} emptyDescription="No verified sales in this category yet." />200 </Card>201 <Card>202 <CardHeader title="Latest sales" action={<Link href={`/sales?category=${slug}`} className="text-muted hover:text-fg">All sales →</Link>} />203 <SalesTable items={recent} emptyDescription="No sales in this category yet." />204 </Card>205 <Card className="xl:col-span-2">206 <CardHeader title="Live listings" subtitle="Asking prices are not market values" action={<Link href={`/listings?category=${slug}`} className="text-muted hover:text-fg">All listings →</Link>} />207 <ListingsTable items={listings.items} />208 </Card>209 <Card>210 <CardHeader title="Biggest auctions" subtitle="Lots by hammer, bid or high estimate" action={<Link href="/auctions" className="text-muted hover:text-fg">Auctions →</Link>} />211 <LotsTable items={lots} />212 </Card>213 <Card>214 <CardHeader title="Population trends" subtitle="Total graded population reported per grader" />215 {popByGrader.size ? (216 <div className="p-4">217 <LineChart ariaLabel="Population trend" height={200} series={[...popByGrader.entries()].map(([g, pts]) => ({ id: g, label: g.toUpperCase(), points: pts }))} />218 </div>219 ) : (220 <EmptyState title="No population reports" description="Population data appears when grading-company connectors publish reports for assets in this category." className="py-8" />221 )}222 </Card>223 <Card>224 <CardHeader title="Rare Radar" action={<Link href="/radar" className="text-muted hover:text-fg">Open radar →</Link>} />225 {radar.length ? (226 <ul className="divide-y divide-border text-[12px]">227 {radar.map((r) => (228 <li key={r.id} className="flex items-center justify-between gap-3 px-4 py-2">229 <Link href={`/asset/${r.assetSlug}`} className="truncate font-medium text-fg hover:underline">230 {r.assetTitle}231 </Link>232 <span className="shrink-0 text-muted">233 {humanize(r.kind)} · {fmtRelative(r.detectedAt)}234 </span>235 </li>236 ))}237 </ul>238 ) : (239 <EmptyState title="Radar is quiet" className="py-8" />240 )}241 </Card>242 <Card>243 <CardHeader title="News" action={<Link href={`/news?category=${slug}`} className="text-muted hover:text-fg">All news →</Link>} />244 {news.items.length ? (245 <ul className="divide-y divide-border text-[12px]">246 {news.items.map((n) => (247 <li key={n.id} className="px-4 py-2">248 <a href={n.url} target="_blank" rel="noopener nofollow" className="font-medium text-fg hover:underline">249 {n.title}250 </a>251 <span className="block text-[11px] text-muted">252 {n.sourceName} · {n.publishedAt ? fmtDate(n.publishedAt) : 'undated'}253 </span>254 </li>255 ))}256 </ul>257 ) : (258 <EmptyState title="No news indexed" description="News connectors are not yet publishing for this market." className="py-8" />259 )}260 </Card>261 </section>262 );263}264265async function Structure({ slug }: { slug: string }) {266 const [children, sets, brands] = await Promise.all([getMarketRows(slug), getSetsInCategory(slug, 40), getBrandsInCategory(slug, 30)]);267 return (268 <section className="mt-4 grid gap-4">269 {children.length ? (270 <Card>271 <CardHeader title="Subcategories" />272 <MarketTable rows={children} showPhase />273 </Card>274 ) : null}275 <div className="grid gap-4 lg:grid-cols-2">276 <Card>277 <CardHeader title="Sets & releases" subtitle={`${fmtNum(sets.length)}${sets.length >= 40 ? '+' : ''} releases catalogued · ranked by sales, then size`} action={<Link href={`/markets/${slug}?sort=number#assets`} className="text-muted hover:text-fg">Browse by set →</Link>} />278 {sets.length ? (279 <ul className="grid grid-cols-1 text-[12px] sm:grid-cols-2">280 {sets.map((s) => (281 <li key={s.slug} className="flex items-center gap-2 border-b border-border px-4 py-1.5">282 <Thumb src={s.thumb} alt="" size={28} />283 <Link href={`/set/${s.slug}`} className="min-w-0 flex-1 truncate font-medium text-fg hover:underline">284 {s.name}285 {s.code ? <span className="ml-1 text-subtle">{s.code}</span> : null}286 {s.releaseYear ? <span className="ml-1 text-subtle">{s.releaseYear}</span> : null}287 </Link>288 <span className="num shrink-0 text-muted">289 {fmtNum(s.assets)}{s.sales ? ` · ${fmtNum(s.sales)} sales` : ''}{s.priced ? ` · ${fmtNum(s.priced)} priced` : ''}290 </span>291 </li>292 ))}293 </ul>294 ) : (295 <EmptyState title="No sets catalogued" className="py-8" />296 )}297 </Card>298 <Card>299 <CardHeader title="Brands" subtitle="Median RIV of priced assets" />300 {brands.length ? (301 <div className="p-4">302 <BarChart ariaLabel="Assets per brand" data={brands.slice(0, 12).map((b) => ({ label: b.brand, value: b.assets, sublabel: `${b.priced} priced · median RIV ${b.medianRivUsd != null ? fmtMoney(b.medianRivUsd) : '—'}`, href: `/brand/${encodeURIComponent(b.brand)}` }))} />303 </div>304 ) : (305 <EmptyState title="No brand data" className="py-8" />306 )}307 </Card>308 </div>309 <p className="text-[11px] text-subtle">310 Unavailable metrics render as <Unavailable /> rather than estimates (§192).311 </p>312 <span className="hidden">313 <Delta value={null} />314 </span>315 </section>316 );317}318